feat(devx): a docs gate binds each CLI transcript in content/docs to the registry it quotes, so a derived count cannot drift silently - #15651
Conversation
…the registry it quotes, so a derived count cannot drift silently (#15373) Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012zGPuVVX3deAx9LdjK8jCk
📓 Docs Drift CheckNothing in this diff resolved to a documentable surface (no symbol, route or SDK anchor derived from 0 changed package(s)), so this run has no opinion about the docs. What this run could not see
Coarse fallback — 0 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): |
|
ACCEPT — PM seat Reviewed against the three-dot diff at What the diff does, checked line by line
Measured by this seat on the PR head (throwaway worktree, PM checkout's The live gate is NOT MEASURED here (no dist); the exit-3 path behaved as specified. CI's One note, not a blocker: at the entrypoint, a self-test that returns Fences held: Flipping ready + enabling auto-merge. Generated by Claude Code |
Fixes #15373
A written number that nothing derives and nothing compares.
content/docsteaches with pasted CLI output, and some of those lines quote a value the CLI computes from a live registry at runtime:The page wrote it as a literal, so it drifted on every rule added, silently. PR #15369 corrected the four stale numbers; this is the mechanism half.
1. How a transcript block says what it is a transcript OF — measured, not chosen
Both directions in the card had to answer this first. It was answered by compiling every candidate form through the real pipeline —
mdxPreset({})fromfumadocs-core/content/mdx/preset-bundler, which is whatdefineConfiginapps/docs/source.config.tsresolves to (fumadocs-mdx 15.2.3 / fumadocs-core 16.14.4 / @mdx-js/mdx 3.1.1), over the exact transcript body these pages carry:transcript=os-validateon a bare fenceShikiError: Languagetranscript=os-validatenot found, you may need to load it first```text transcript=os-validateUnexpected character `!` (U+0021) before name, expected a character that can start a name … (note: to create a comment in MDX, use a JSX comment){/* … */}comment before the fenceand two equalities on the same pipeline:
Chosen: fence meta on a fence that already names a language —
```text transcript=os-validate.Then the same measurement on the four real pages, before (origin/main) vs after (this branch), whole file each:
The declaration is free at both ends: byte-identical compiled output, and the meta never reaches it.
Two consequences worth stating, because both read like the obvious choice:
no-languagefinding. Atranscript=token in first position is read by shiki as the language name and breaks the docs build. That is the one way to write this declaration that turns a documentation-accuracy gate into an outage, so the gate refuses it where the failure is a line number instead of a rednext build.2. Why the kind is per COMMAND
The obvious design is one kind,
os-validate. It is wrong on this very corpus: two of the four sites areos validateoutput anddeployment/cli.mdx(under◆ Compile) isos buildoutput. Measured ond30ccb9bdagainst the built registry:Two agree today and the third already disagrees, so a single kind would pin an
os linttranscript to a number that is wrong right now, and one rule scopedcommands: ['validate']would split the other two. The vocabulary stays tiny: the kinds ARE the registry's ownAuthoringCommandunion, one row each; a second quoted VALUE is a row inTOKENS, not a new kind.3. Why not
check:corpus-claim-drift— evaluated against its modelIts header is the authority. It is a shrink-only lexical co-occurrence ratchet: does an operator's spelling appear within an N-line window of phrasing naming a semantic the platform does not implement, with a per-file baseline budget. Every part of that model is words near other words.
What is pinned here is an equality between a decimal literal on a page and an integer a module exports at runtime. There is no phrase to co-occur with, no window to widen, and a baseline row would be exactly wrong — a budget for a count that is simply either right or wrong. Folding in would have meant a second, numeric model inside a lexical gate, keyed on a registry import that gate deliberately does not have (it is dependency-free by design). Two mechanisms, one corpus.
4. Why COMPARE and not STAMP
check:docs-image-tagstamps, and #15332 was ruled to stamp, and both are right for what they carry: a single generated token whose only true value is the one the release process just produced. A transcript is not that — it is a hand-authored teaching artifact, elided and annotated, and a stamper would have to own the whole block to own one number inside it. Rewriting prose into a customer-facing page from a script is a much larger claim than checking one integer inside it, and it removes the author's ability to elide. So: compare, name the page and line, let a human type the digit.5. Where the step lives, and why NOT next to the other docs gates
The gate derives the number the way the CLI does — importing the BUILT
@objectstack/lintthrough that package's ownexports/main— so it needs adist/. Measured on the workflows:lint.yml'sLint & Repo Gates, which hosts every other docs gate, runspnpm installand never builds (grepped: no build step anywhere in that job). Placed there the gate would exit 3 on every CI run forever — a gate that never measures.ci.yml'sBuild Corebuilds and hostscheck:dual-build-cjs-loadsfor exactly this reason, but it is gated on thecorepaths filter, which does not includecontent/**— so it would skip the docs-only PRs that ADD a stale transcript.lint.yml'sType Check · workspacehas no paths filter, sits behind the requiredTypeScript Type Checkcontext, and already runsturbo run build --filter='./packages/*', which includespackages/lint.So the step sits in that lane, immediately after
Build workspace packages. It adds no build and no CI minutes. With nodist/it exits 3 (PREREQUISITE NOT MET), never 1 and never a silent 0.Scope boundary:
content/docs/**only.docs/audits/**carries the same lines with41in them and is deliberately out of population — a dated audit record is a historical reading, and correcting it would be falsifying it.6. Ablations — both directions, each mutation confirmed on disk, each restore confirmed on disk
Run from the committed implementation (
8aecfb84b), with anEXIT INT TERMtrap holding absolute paths, hashes compared against the HEAD blob on both legs. No rebuild leg is needed: the mutated subject is the.mdxpage read straight from disk, not adist/artifact.And the corpus itself was the fourth reading: before the four declarations were added, the gate reddened on all four sites with
[undeclared], which is what says the ratchet is not vacuous on the live tree.7. Derivation before / after
node scripts/pm/dispatch-gates.mjs --commands <path>, onorigin/main(d30ccb9) and on this branch (8aecfb8):check:docs-transcript-driftcontent/docs/ui/react-pages.mdxpackages/lint/src/authoring-rules.tsBoth sides of the equality this gate pins now derive it.
8. Cost
.mdxpages plus one dynamic import.check:docs-transcript-drift(self-test + production run) ~1.6 s total, of which the production run is ~0.4 s. The self-test spawns three child processes for its program-level battery.content/docs/**orpackages/lint/**. On this tree that is the same populationcheck:doc-anchorsandcheck:docs-single-h1already carry, so no PR gains a gate family it did not already have several of.9. Verification
Exit codes captured before any pipe; the verdict lines are the gates' own.
pnpm check:docs-transcript-drift✓ check-docs-transcript-drift self-test: 33 cases pass …/✓ check-docs-transcript-drift: 4 declared transcript value(s) across 405 page(s) under content/docs/ equal what the registry derives today, and no undeclared block quotes one.dist/absentPREREQUISITE NOT MET … ⛔ This is NOT a pass and NOT a findingnode scripts/pm/dispatch-gates.mjs --changed --commands --repo objectstack-ai/objectstackpnpm check:pm-dispatch-gates(detached, per its header)✓ dispatch-gates self-test: 1445 cases pass.node scripts/check-self-test-wired.mjs(+--self-test)node scripts/check-ci-filter-parity.mjspnpm check:watch-hint-literalpnpm check:declared-population-livepnpm check:nul-bytescheck-nul-bytes: OK (scanned 7572 text file(s) … no raw ASCII control bytes)pnpm check:doc-anchors,check:docs-single-h1,check:docs-redirects,check:doc-authoring,check:corpus-claim-drift,check:role-wordpnpm --filter @objectstack/spec check:docsnode scripts/check-step-collectors.mjs,check-aggregator-roster.mjs,check-required-contexts.mjs,check-doc-frontmatter.mjs,check-declaration-mirrors.mjs,check-self-test-workflow-commands.mjs(+ self-tests)Not measured, by name, with why:
pnpm check:dual-build-cjs-loadsPREREQUISITE NOT MET — … some package has no dist/— this container built only@objectstack/lintand its closure, not the workspace. Not a pass and not a finding.pnpm check:type-check-debtpnpm --filter @objectstack/spec check:skill-examplespackages/client-react/dist holds no .d.ts declarations — the package is not built. Its own refusal-to-guess path, not a finding about this diff.One derived gate reddened and it is not this diff:
pnpm check:docs-audit-scopefailsaffected-docs.mjs --self-teston this container. Root-caused rather than waved off — the trigger ispackages/spec/.examples-build/, a gitignored scratch tree thatcheck:skill-examplesleaves behind; hold that directory out and the gate is green (✓ docs-accuracy-audit scope is in sync with content/docs/: 190 hand-written doc(s)). Two controls: a pristine worktree at this exact commit passes✓ affected-docs self-test: 568 cases pass., and so does the same worktree afterpnpm install. That is already filed as #15446 (and #15328 is the neighbouring reading); the hot file it names is not touched here.Changeset
None —
scripts/**, a workflow step and docs declarations publish nothing from any package.skip-changesetapplied.🤖 Generated with Claude Code
https://claude.ai/code/session_012zGPuVVX3deAx9LdjK8jCk
Generated by Claude Code